Skip to content

machine/esp32: add PWM support using the LEDC peripheral for classic esp32 board - #5669

Merged
deadprogram merged 2 commits into
tinygo-org:devfrom
zombieleet:esp32-pwm
Sep 20, 2026
Merged

deadprogram merged 2 commits into
tinygo-org:devfrom
zombieleet:esp32-pwm

Conversation

@zombieleet

@zombieleet zombieleet commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Why

The classic ESP32 is the only ESP32 chip in TinyGo without PWM. This adds it.

It uses the high speed half of the LEDC peripheral. This gives 4 timers
(PWM0 to PWM3) and 8 channels. You can use any pin, because the signal goes
through the GPIO matrix.

This closes the ESP32 part of #5186. Thanks to @jespino for the first version
and for the hardware research. His register values were a good check while I
wrote this.

What changed

The C3 and the S3 have LEDC PWM. Most of machine_esp32xx_pwm.go is the same
for all of these chips. This change uses that code again. It does not add a
second driver for the same peripheral.

Three functions in that file work only on the newer chips. They move to a file
for each chip.

function why it is different
enableClock The C3 and S3 turn on the clock in SYSTEM. This chip uses DPORT. It also has no LEDC.CONF_CLK_EN bit.
setTimerConf The timer registers here are HSTIMER0_CONF, not TIMER0_CONF.
chanOp The channel registers here are HSCH0_CONF0, not CH0_CONF0.

The files are now:

machine_esp32xx_pwm.go       esp32 || esp32c3 || esp32s3   shared code
machine_esp32xx_ls_pwm.go    esp32c3 || esp32s3            new, moved code
machine_esp32c3_pwm.go       esp32c3                       invert case removed
machine_esp32s3_pwm.go       esp32s3                       invert case removed
machine_esp32_pwm.go         esp32                         new

machine_esp32xx_ls_pwm.go holds code that moved out of the shared file. In the
first commit that code is unchanged, so the C3 and the S3 keep their behaviour
and their binaries. The second commit then edits it, along with the two per chip
files, for the fixes listed below.

This also adds src/examples/pwm/esp32-coreboard-v2.go and one line in the
smoketest-esp target.

Why these decisions

Why this is not a change to #5186.

That PR is older than the esp32xx shared layer from #5215. It adds a type of
its own in one file of 601 lines. The tree would then have two LEDC drivers.
One uses the generated SVD setters. The other uses register offsets and masks
written by hand. The shared type keeps one driver. It also checks the register
names against the description from Espressif.

Why the high speed block.

This chip has a high speed block and a low speed block. High speed channels
apply a duty change themselves at the end of the period. There is no glitch and
no PARA_UP bit to set. The low speed block can come later if somebody needs
the 8 more channels.

Why a new file and not a change to the shared file.

TICK_SEL has the opposite meaning on this chip.

ESP32 high speed timers   1 = APB_CLK 80MHz   0 = REF_TICK 1MHz
C3 and S3 low speed       the shared code writes 0

A shared setTimerConf would select the 1MHz clock on the ESP32. All
frequencies would then be 80 times too slow. A file for each chip prevents
this.

Why the C3 and S3 still use a long switch.

The ESP32 chanOp finds its register with pointer arithmetic, because the
channel registers repeat every 0x14 bytes and outFunc in machine_esp32.go
already works that way. The C3 and S3 registers repeat too, so the same change
would suit them, but it would rewrite files this PR only needs to touch for the
fixes. That is a good follow up.

Why GPIO18 and GPIO19 in the example.

These pins have no function at boot. They also do not go to an LED on the
board.

MCPWM is not in this change. Motor control PWM is a different peripheral. It
needs dead time and fault inputs, so it needs a different API.

Fixes in the shared code

These are older than this PR, but the ESP32 support makes them reachable. Most
came out of review.

The four timers share the eight channels. Each LEDCPWM had its own channel
table starting at index 0, so PWM0.Channel(a) and PWM1.Channel(b) both
returned channel 0 and both wrote hardware channel 0. The table now belongs to
the peripheral and records which timer owns each entry.

Configure cleared the whole table, which with a shared table erases the
channels of the other timers. It now releases only its own, and a released
channel also stops driving its pin, because the table alone does not stop the
hardware.

Set and SetInverting took a channel number without checking the owner.
One timer could reprogram another timer's channel with the duty scaled for the
wrong resolution. Both now go through LEDCPWM.owns.

SetInverting did not invert. It wrote IDLE_LV, which only sets the pin
level when SIG_OUT_EN is 0. The GPIO matrix does the inversion with INV_SEL
in FUNCn_OUT_SEL_CFG, and the SVD already gives that bit for each chip.
chanOp no longer has an invert operation.

enableClock pulsed the LEDC reset on every Configure, which clears every
timer and channel. It now runs once on all three chips. It also runs before the
period is checked, so a call returning ErrPWMPeriodTooLong used to destroy
every running output on its way out.

A period above one second divided by zero. freq truncates to 0 and the
divider panics. Configure returns ErrPWMPeriodTooLong.

Tests

  • gofmt
  • Builds for esp32-coreboard-v2, esp32-generic, esp32c3-generic,
    esp32c3-supermini, esp32s3-generic and xiao-esp32s3
  • After the first commit the examples/pwm binaries for
    esp32c3-supermini and xiao-esp32s3 are byte for byte identical to
    dev. The second commit changes them on purpose
  • Tested on an ESP32-WROVER board

On that board the four timers now return channels 0, 1, 2 and 3 for four pins,
where before they all returned channel 0. Configuring a second timer leaves the
channel of the first one untouched. With the duty at the top a pin reads 100
percent high, SetInverting makes it 0 percent, and switching it off again
returns it to 99 percent.

A pin driven at full duty reads 0 percent after a second Configure releases
its channel. A Set from a timer that does not own the channel leaves the
output at 100 percent. A Configure with a period of 2 seconds returns
pwm: period too long instead of panicking. Writing a different value to all 4
timers and all 8 channels through the new pointer arithmetic, then reading each
back through the named registers, gives the same values.

A servo on GPIO15 turns from 0 to 180 degrees and back. The movement is smooth.
A servo moves only with pulses between 0.5ms and 2.5ms that repeat near 50Hz. A
wrong divider puts the arm in the wrong place. A wrong duty stops the movement.

One known limit

The shared Configure cuts the clock divider to a whole number.

divActual = 80_000_000 / (freq << dutyRes)

At 50Hz with 14 bit resolution this is 97.66, and the code uses 97. The
period is then 19.87ms and not 20ms. Pulses are about 0.67% too short.

This is the behaviour that the C3 and the S3 have now. This change does not add
it. A divider that rounds to the nearest number would give about 0.35%. That
changes the output of all three chips, so it belongs in its own PR.

@zombieleet
zombieleet marked this pull request as ready for review September 10, 2026 23:04
@zombieleet

zombieleet commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor Author
IMG_4246.mov

@zombieleet zombieleet changed the title machine/esp32: add PWM support using the LEDC peripheral machine/esp32: add PWM support using the LEDC peripheral for classic esp32 board Sep 12, 2026
@zombieleet

zombieleet commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor Author

Hi @deadprogram PTAL

@deadprogram

deadprogram commented Sep 14, 2026 •

Copy link
Copy Markdown
Member

Thanks @zombieleet. Notes below, edited from an automated review.

I built the three targets. The esp32c3-supermini and xiao-esp32s3 binaries are the same before and after the change, so the move of the code did not change them. esp32-coreboard-v2 builds.

  1. chanOp can be much shorter. The HSCH registers repeat every 0x14 bytes. HSCH0_CONF0 is at 0x0, HSCH1_CONF0 is at 0x14, and HSCH7_CONF0 is at 0x8C. You can find a channel register with pointer arithmetic instead of a switch. machine_esp32.go already does this in outFunc and inFunc. This removes about 250 lines.

The following items are not new problems in this PR. What do you think, would it be a good idea to address them now, or to leave for a separate PR?

  1. SetInverting does not invert the output. It writes IDLE_LV only, and IDLE_LV sets the pin level when SIG_OUT_EN is 0. To invert the live signal you must set the INV_SEL bit of FUNCn_OUT_SEL_CFG in the GPIO matrix, and Pin.configure clears that bit. This comes from the shared code, not from this change. But examples/pwm calls SetInverting, and this change adds that example to the ESP32 smoke test. Channel B will not show an inverted output on the board.

  2. PWM0 to PWM3 are four timers, but they share the same eight channels. Each LEDCPWM has its own channelPin array that starts at index 0. PWM0.Channel(pinA) and PWM1.Channel(pinB) both return channel 0 and both write hardware channel 0. The second call replaces the first. This also comes from the shared code. It limits the four timers to one timer at a time.

  3. Configure calls enableClock, and enableClock pulses LEDC_RST. That reset clears the whole LEDC block, so a second Configure on another timer erases the first one. This also comes from the shared code, but enableClock is new here, so this may be the place to fix it.

@deadprogram

Copy link
Copy Markdown
Member

@zombieleet did you see my most recent feedback? Thanks.

@zombieleet

zombieleet commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor Author

Hi @deadprogram Yah, I saw it. I am currently in a different city (without my personal laptop) for company offsite. By Friday/Saturday I will make the update when I am back to Berlin.

Sorry about this :(

@deadprogram

Copy link
Copy Markdown
Member

Hi @deadprogram Yah, I saw it. I am currently in a different city (without my personal laptop) for company offsite. By Friday/Saturday I will make the update when I am back to Berlin.

Sorry about this :(

No worries, and thank you! 😄

The classic ESP32 had no PWM. This adds it.

It uses the high speed half of the LEDC peripheral. This gives 4 timers
(PWM0 to PWM3) and 8 channels. You can use any pin, because the signal
goes through the GPIO matrix.

The ESP32-C3 and the ESP32-S3 have LEDC PWM. Most of that code is the
same for all of these chips. This change uses that code again. It does
not add a second driver for the same peripheral.

Three functions in the shared file work only on the newer chips. They
move to a file for each chip.

  enableClock   The C3 and S3 turn on the clock in SYSTEM. This chip
                uses DPORT. It also has no LEDC.CONF_CLK_EN bit.
  setTimerConf  The timer registers here are HSTIMER0_CONF. On the
                other chips they are TIMER0_CONF.
  chanOp        The channel registers here are HSCH0_CONF0. On the
                other chips they are CH0_CONF0.

The files are now:

  machine_esp32xx_pwm.go     The shared part. It builds for esp32 also.
                             It calls enableClock and setTimerConf.
  machine_esp32xx_ls_pwm.go  New. It holds the two low speed functions.
                             The code moved without a change. It builds
                             for esp32c3 and esp32s3 only.
  machine_esp32_pwm.go       New. The versions for the classic ESP32.

The channel and timer registers repeat at a fixed distance, so chanOp
and setTimerConf use pointer arithmetic to find them. Pin.outFunc in
machine_esp32.go does the same. Channel registers repeat every 0x14
bytes and timer registers every 0x8.

Two hardware details are important.

TICK_SEL has the opposite meaning on this chip. Here 1 selects APB_CLK
at 80MHz and 0 selects REF_TICK at 1MHz. The low speed timers on the C3
and the S3 write 0. A 0 here makes all frequencies 80 times too slow.

There is no PARA_UP bit. On the C3 and the S3 you set PARA_UP to apply a
change to a channel. High speed channels apply the change themselves at
the end of the period.

The reset of the LEDC block happens one time only. The reset clears all
timers and all channels, so a Configure of a second timer would erase
the first one.

Tests:

The examples/pwm binaries for esp32c3-supermini and xiao-esp32s3 are the
same byte for byte before and after this change. The move of the code
did not change it.

Tested on an ESP32-WROVER board. A program wrote a different value to
all 4 timers and all 8 channels through the pointer arithmetic, then
read the values back through the named registers. All of them agree.

A servo on GPIO15 turns from 0 to 180 degrees and back. The movement is
smooth. This shows that the timing is correct. A servo moves only with
pulses between 0.5ms and 2.5ms that repeat near 50Hz.

Motor control PWM (the MCPWM peripheral) is not in this change. It needs
a different API.
@zombieleet

Copy link
Copy Markdown
Contributor Author

@deadprogram PTAL 🙏🏿

@deadprogram

Copy link
Copy Markdown
Member

Thanks @zombieleet. Notes below, edited from an automated review.

Almost there just a could of comments that should be adjusted:

  1. The header of machine_esp32xx_pwm.go says "Three functions
    are different per chip" and then lists four. Probably just say "These functions"
    so no need too keep count.

  2. The comments on ledcChanOpInit and ledcChanOpSetDuty in
    machine_esp32xx_pwm.go still mention PARA_UP. The ESP32 has no such bit,
    as machine_esp32_pwm.go explains.

Thank you!

Several problems in the shared LEDC code. They are older than the PWM
support for the classic ESP32, but that change makes them reachable.

The four timers share one set of 8 channels in the hardware, yet each
LEDCPWM had a table of its own that starts at index 0. PWM0.Channel(a)
and PWM1.Channel(b) both returned channel 0 and both wrote hardware
channel 0, so only one timer worked at a time. The table now belongs to
the peripheral and records the timer that owns each entry.

Configure cleared that whole table, which with a shared table erases the
channels of the other timers, so it now releases only its own. Releasing
a channel also disables its output, because the table alone does not stop
the hardware from driving the pin.

Set and SetInverting took a channel number without checking who owns it.
One timer could reprogram another timer's channel, with the duty scaled
for the wrong resolution. Both now go through LEDCPWM.owns.

SetInverting did not invert. It wrote IDLE_LV, which only sets the pin
level when SIG_OUT_EN is 0, so it has no effect on a running signal. The
GPIO matrix does the inversion with INV_SEL in FUNCn_OUT_SEL_CFG, and the
SVD already gives that bit for each chip. chanOp has no invert operation
now, so the operation and its parameter are gone from all three chips.

enableClock pulses the LEDC reset, which clears every timer and channel.
It now runs once for the C3 and S3 as well as for the classic ESP32.
Before this, a Configure of one timer erased all the others, and a call
that failed the period check destroyed running outputs on its way out.

A period above one second makes the frequency truncate to zero, and the
divider then divides by zero. Configure returns ErrPWMPeriodTooLong.

Tested on an ESP32-WROVER board.

  PWM0 to PWM3 gave channels 0, 1, 2 and 3 for 4 pins, where before all
  four gave channel 0.

  A pin driven at full duty read 100 percent high, and 0 percent after
  a second Configure released its channel.

  A Set from a timer that does not own the channel left the output at
  100 percent, so it was refused.

  SetInverting took the same pin from 100 percent to 0 percent, and back
  to 99 percent when switched off.

  Configure with a period of 2 seconds returned "pwm: period too long"
  instead of a panic.

The reads use Pin.Get on the output pin, which works because
Pin.configure always sets FUN_IE.
@zombieleet

Copy link
Copy Markdown
Contributor Author

@deadprogram done, thanks 🙏🏿

@deadprogram deadprogram left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for making the requested changes. Tested on my esp32-coreboard-v2 and works as expected.

@deadprogram
deadprogram merged commit bc7470b into tinygo-org:dev Sep 20, 2026
33 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants